/*
  This file is part of the PhantomJS project from Ofi Labs.

  Copyright (C) 2011 Ariya Hidayat <ariya.hidayat@gmail.com>
  Copyright (C) 2012 Ivan De Marino <ivan.de.marino@gmail.com>

  Redistribution and use in source and binary forms, with or without
  modification, are permitted provided that the following conditions are met:

    * Redistributions of source code must retain the above copyright
      notice, this list of conditions and the following disclaimer.
    * Redistributions in binary form must reproduce the above copyright
      notice, this list of conditions and the following disclaimer in the
      documentation and/or other materials provided with the distribution.
    * Neither the name of the <organization> nor the
      names of its contributors may be used to endorse or promote products
      derived from this software without specific prior written permission.

  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
  ARE DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY
  DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
  (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
  LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
  ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
  (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
  THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/

#include "cookiejar.h"

#include <QDateTime>
#include <QSettings>
#include <QTimer>
#include <QDebug>
#include "webpage.h"

#define COOKIE_JAR_VERSION      1
#define COOKIE_JAR_DEBUG        0


static inline bool isParentPath(QString path, QString reference)
{
    if (!path.endsWith(QLatin1Char('/')))
        path += QLatin1Char('/');
    if (!reference.endsWith(QLatin1Char('/')))
        reference += QLatin1Char('/');
    return path.startsWith(reference);
}

static inline bool isParentDomain(QString domain, QString reference)
{
    if (!reference.startsWith(QLatin1Char('.')))
        return domain == reference;

    return domain.endsWith(reference) || domain == reference.mid(1);
}

// Operators needed for Cookie Serialization
QT_BEGIN_NAMESPACE
QDataStream &operator<<(QDataStream &stream, const QList<QNetworkCookie> &list)
{
    stream << COOKIE_JAR_VERSION;
    stream << quint32(list.size());
    for (int i = 0; i < list.size(); ++i)
        stream << list.at(i).toRawForm();
    return stream;
}

QDataStream &operator>>(QDataStream &stream, QList<QNetworkCookie> &list)
{
    list.clear();

    quint32 version;
    stream >> version;

    if (version != COOKIE_JAR_VERSION)
        return stream;

    quint32 count;
    stream >> count;
    for(quint32 i = 0; i < count; ++i)
    {
        QByteArray value;
        stream >> value;
        QList<QNetworkCookie> newCookies = QNetworkCookie::parseCookies(value);
        if (newCookies.count() == 0 && value.length() != 0) {
            qWarning() << "CookieJar: Unable to parse saved cookie:" << value;
        }
        for (int j = 0; j < newCookies.count(); ++j)
            list.append(newCookies.at(j));
        if (stream.atEnd())
            break;
    }
    return stream;
}
QT_END_NAMESPACE

// private:
CookieJar::CookieJar(QString cookiesFile, QObject *parent)
    : QNetworkCookieJar(parent)
    , m_cookieStorage(new QSettings(cookiesFile, QSettings::IniFormat, this))
    , m_enabled(true)
{
    load();
}

// public:
CookieJar *CookieJar::instance(QString cookiesFile)
{
    static CookieJar *singleton = NULL;
    if (!singleton) {
#if COOKIE_JAR_DEBUG
        if (cookiesFile.isEmpty()) {
            qDebug() << "CookieJar - Created but will not store cookies (use option '--cookies-file=<filename>' to enable persistent cookie storage)";
        } else {
            qDebug() << "CookieJar - Created and will store cookies in:" << cookiesFile;
        }
#endif
        singleton = new CookieJar(cookiesFile);
    }
    return singleton;
}

CookieJar::~CookieJar()
{
    // On destruction, before saving, clear all the session cookies
    purgeSessionCookies();
    save();
}

bool CookieJar::setCookiesFromUrl(const QList<QNetworkCookie> & cookieList, const QUrl &url)
{
    // Update cookies in memory
    if (isEnabled()) {
        QNetworkCookieJar::setCookiesFromUrl(cookieList, url);
        save();
    }
    // No changes occurred
    return false;
}

QList<QNetworkCookie> CookieJar::cookiesForUrl(const QUrl &url) const
{
    if (isEnabled()) {
        return QNetworkCookieJar::cookiesForUrl(url);
    }
    // The CookieJar is disabled: don't return any cookie
    return QList<QNetworkCookie>();
}

bool CookieJar::setCookiesFromUrl(unsigned long identifier, const QList<QNetworkCookie> &cookieList, const QUrl & url)
{
    // Update cookies in memory
    if (isEnabled()) {
        WebPage *cpage = getPage(identifier);
        if (cpage != NULL) {
            QString defaultDomain = url.host();
            QString pathAndFileName = url.path();
            QString defaultPath = pathAndFileName.left(pathAndFileName.lastIndexOf(QLatin1Char('/'))+1);
            if (defaultPath.isEmpty())
                defaultPath = QLatin1Char('/');

            int added = 0;
            QDateTime now = QDateTime::currentDateTime();
            Q_FOREACH (QNetworkCookie cookie, cookieList) {
                bool isDeletion = !cookie.isSessionCookie() &&
                    cookie.expirationDate() < now;

                // validate the cookie & set the defaults if unset
                if (cookie.path().isEmpty())
                    cookie.setPath(defaultPath);
                // don't do path checking. See http://bugreports.qt-project.org/browse/QTBUG-5815
                //        else if (!isParentPath(pathAndFileName, cookie.path())) {
                //            continue;           // not accepted
                //        }
                if (cookie.domain().isEmpty()) {
                    cookie.setDomain(defaultDomain);
                } else {
                    // Ensure the domain starts with a dot if its field was not empty
                    // in the HTTP header. There are some servers that forget the
                    // leading dot and this is actually forbidden according to RFC 2109,
                    // but all browsers accept it anyway so we do that as well.
                    if (!cookie.domain().startsWith(QLatin1Char('.')))
                        cookie.setDomain(QLatin1Char('.') + cookie.domain());

                    QString domain = cookie.domain();
                    if (!(isParentDomain(domain, defaultDomain)
                                || isParentDomain(defaultDomain, domain)))
                        continue; // not accepted

                    //TODO : the check for effective TLDs makes the "embedded dot" rule from RFC 2109 section 4.3.2
                    //       redundant; the "leading dot" rule has been relaxed anyway, see above
                    //       we remove the leading dot for this check
                    //       /*if (qIsEffectiveTLD(domain.remove(0, 1)))
                    //           continue; // not accepted*/
                }

                QList<QNetworkCookie> allCookies = cpage->getPrivateCookies();
                for (int i = 0; i < allCookies.size(); ++i) {
                    // does this cookie already exist?
                    const QNetworkCookie &current = allCookies.at(i);
                    if (cookie.name() == current.name() &&
                            cookie.domain() == current.domain() &&
                            cookie.path() == current.path()) {
                        // found a match
                        allCookies.removeAt(i);
                        break;
                    }
                }

                // did not find a match
                if (!isDeletion) {
                    int countForDomain = 0;
                    for (int i = allCookies.size() - 1; i >= 0; --i) {
                        // Start from the end and delete the oldest cookies to keep a maximum count of 50.
                        const QNetworkCookie &current = allCookies.at(i);
                        if (isParentDomain(cookie.domain(), current.domain())
                                || isParentDomain(current.domain(), cookie.domain())) {
                            if (countForDomain >= 49)
                                allCookies.removeAt(i);
                            else
                                ++countForDomain;
                        }
                    }

                    allCookies += cookie;
                }
                cpage->setPrivateCookies(allCookies);
            }
        }

        //QNetworkCookieJar::setCookiesFromUrl(cookieList, url);
        //save();
    }
    // No changes occurred
    return false;
}

WebPage* CookieJar::getPage(unsigned long identifier) const
{
    WebPage *page = NULL;
    for (int i = 0; i < m_pages.size() ; i ++) {
        if (m_pages.at(i)->containsDocumentIdentifier(identifier)) {
            page = m_pages.at(i);
            break;
        }
    }
    return page;
}

QList<QNetworkCookie> CookieJar::cookiesForUrl(unsigned long identifier, const QUrl &url) const
{
    if (isEnabled()) {
/*
        QList<QNetworkCookie> icookies = QNetworkCookieJar::cookiesForUrl(url);
        QMap<QString, QNetworkCookie> cm;
        for (int i = 0; i < icookies.size(); i ++)
            cm.insert(QString(icookies.at(i).name()), icookies.at(i));

        WebPage *cpage = getPage(identifier);

        if (cpage != NULL) {
            QList<QNetworkCookie> tcookies = cpage->getPrivateCookies();
            for (int i = 0; i < tcookies.size(); i ++) {
                QString name(tcookies.at(i).name());
                if( cm.contains(name) ) {
                    QNetworkCookie tc = cm.value(name);
                    // only cover cookie's value
                    tc.setValue(tcookies.at(i).value());
                    cm.insert(name, tc);
                } else {
                    cm.insert(name, tcookies.at(i));
                }
            }
        }

        return cm.values();
        */
        WebPage *cpage = getPage(identifier);
        if (cpage != NULL)
            return cpage->getPrivateCookies();
    }
    // The CookieJar is disabled: don't return any cookie
    return QList<QNetworkCookie>();
}

bool CookieJar::addCookie(const QNetworkCookie &cookie, const QString &url)
{
    if (isEnabled() && (!url.isEmpty() || !cookie.domain().isEmpty())) {
        // Save a single cookie
        setCookiesFromUrl(
            QList<QNetworkCookie>() << cookie, //< unfortunately, "setCookiesFromUrl" requires a list
            !url.isEmpty() ?
                url :           //< use given URL
                QString(        //< mock-up a URL
                    (cookie.isSecure() ? "https://" : "http://") +                              //< URL protocol
                    QString(cookie.domain().startsWith('.') ? "www" : "") + cookie.domain() +   //< URL domain
                    (cookie.path().isEmpty() ? "/" : cookie.path())));                          //< URL path

        // Return "true" if the cookie was really set
        if (contains(cookie)) {
            return true;
        }
#if COOKIE_JAR_DEBUG
        qDebug() << "CookieJar - Rejected Cookie" << cookie.toRawForm();
#endif
        return false;
    }
    return false;
}

bool CookieJar::createNewCookie(const QVariantMap &cookie, QNetworkCookie &newCookie)
{
    // The cookie must have a non-empty "name" and a "value"
    if (!cookie["name"].isNull() && !cookie["name"].toString().isEmpty() && !cookie["value"].isNull()) {
        // Name & Value
        newCookie.setName(cookie["name"].toByteArray());
        newCookie.setValue(cookie["value"].toByteArray());

        // Domain, if provided
        if (!cookie["domain"].isNull() && !cookie["domain"].toString().isEmpty()) {
            newCookie.setDomain(cookie["domain"].toString());
        }

        // Path, if provided
        if (!cookie["path"].isNull() || !cookie["path"].toString().isEmpty()) {
            newCookie.setPath(cookie["path"].toString());
        }

        // HttpOnly, false by default
        newCookie.setHttpOnly(cookie["httponly"].isNull() ? false : cookie["httponly"].toBool());
        // Secure, false by default
        newCookie.setSecure(cookie["secure"].isNull() ? false : cookie["secure"].toBool());

        // Expiration Date, if provided, giving priority to "expires" over "expiry"
        QVariant expiresVar;
        if (!cookie["expires"].isNull()) {
            expiresVar = cookie["expires"];
        } else if (!cookie["expiry"].isNull()) {
            expiresVar = cookie["expiry"];
        }

        if (expiresVar.isValid()) {
            QDateTime expirationDate;
            if (expiresVar.type() == QVariant::String) {
                // Set cookie expire date via "classic" string format
                QString datetime = expiresVar.toString().replace(" GMT", "");
                expirationDate = QDateTime::fromString(datetime, "dd MM yyyy hh:mm:ss");
            } else if (expiresVar.type() == QVariant::Double){
                // Set cookie expire date via "number of seconds since epoch"
                // NOTE: Every JS number is a Double.
                // @see http://www.ecma-international.org/publications/files/ECMA-ST/Ecma-262.pdf
                expirationDate = QDateTime::fromMSecsSinceEpoch(expiresVar.toLongLong());
            }

            if (expirationDate.isValid()) {
                newCookie.setExpirationDate(expirationDate);
            }
        }
        return true;
    }
    return false;
}

bool CookieJar::addCookieFromMap(const QVariantMap &cookie, const QString &url)
{
    QNetworkCookie newCookie;
    if (createNewCookie(cookie, newCookie))
        return addCookie(newCookie, url);
    return false;
}

bool CookieJar::addCookies(const QList<QNetworkCookie> &cookiesList, const QString &url)
{
    bool added = false;
    for (int i = cookiesList.length() -1; i >=0; --i) {
        if (addCookie(cookiesList.at(i), url)) {
            // change it to "true" if at least 1 cookie was set
            added = true;
        }
    }
    return added;
}

bool CookieJar::addCookiesFromMap(const QVariantList &cookiesList, const QString &url)
{
    bool added = false;
    for (int i = cookiesList.length() -1; i >= 0; --i) {
        if (addCookieFromMap(cookiesList.at(i).toMap(), url)) {
            // change it to "true" if at least 1 cookie was set
            added = true;
        }
    }
    return added;
}

QList<QNetworkCookie> CookieJar::cookies(const QString &url) const
{
    if (url.isEmpty()) {
        // No url provided: return all the cookies in this CookieJar
        return allCookies();
    } else {
        // Return ONLY the cookies that match this URL
        return cookiesForUrl(url);
    }
}
QVariantList CookieJar::cookiesToMap(const QList<QNetworkCookie> &cookies) const
{
    QVariantList result;
    QNetworkCookie c;
    QVariantMap cookie;

    for (int i = cookies.length() -1; i >= 0; --i) {
        c = cookies.at(i);

        cookie["domain"] = QVariant(c.domain());
        cookie["name"] = QVariant(QString(c.name()));
        cookie["value"] = QVariant(QString(c.value()));
        cookie["path"] = (c.path().isNull() || c.path().isEmpty()) ? QVariant("/") : QVariant(c.path());
        cookie["httponly"] = QVariant(c.isHttpOnly());
        cookie["secure"] = QVariant(c.isSecure());
        if (c.expirationDate().isValid()) {
            cookie["expires"] = QVariant(QString(c.expirationDate().toString("dd MM yyyy hh:mm:ss")).append(" GMT"));
            cookie["expiry"] = QVariant(c.expirationDate().toMSecsSinceEpoch() / 1000);
        }

        result.append(cookie);
    }

    return result;
}

QVariantList CookieJar::cookiesToMap(const QString &url) const
{
    QList<QNetworkCookie> cookiesList = cookies(url);
    return cookiesToMap(cookiesList);
}

QNetworkCookie CookieJar::cookie(const QString &name, const QString &url) const
{
    QList<QNetworkCookie> cookiesList = cookies(url);
    for (int i = cookiesList.length() -1; i >= 0; --i) {
        if (cookiesList.at(i).name() == name) {
            return cookiesList.at(i);
        }
    }
    return QNetworkCookie();
}

QVariantMap CookieJar::cookieToMap(const QString &name, const QString &url) const
{
    QVariantMap cookie;

    QVariantList cookiesList = cookiesToMap(url);
    for (int i = cookiesList.length() -1; i >= 0; --i) {
        cookie = cookiesList.at(i).toMap();
        if (cookie["name"].toString() == name) {
            return cookie;
        }
    }
    return QVariantMap();
}

bool CookieJar::deleteCookie(const QString &name, const QString &url)
{
    bool deleted = false;
    if (isEnabled()) {

        // NOTE: This code has been written in an "extended form" to make it
        // easy to understand. Surely this could be "shrinked", but it
        // would probably look uglier.

        QList<QNetworkCookie> cookiesListAll;

        if (url.isEmpty()) {
            if (name.isEmpty()) {           //< Neither "name" or "url" provided
                // This method has been used wrong:
                // "redirecting" to the right method for the job
                clearCookies();
            } else {                        //< Only "name" provided
                // Delete all cookies with the given name from the CookieJar
                cookiesListAll = allCookies();
                for (int i = cookiesListAll.length() -1; i >= 0; --i) {
                    if (cookiesListAll.at(i).name() == name) {
                        // Remove this cookie
#if COOKIE_JAR_DEBUG
                        qDebug() << "CookieJar - Deleted" << cookiesListAll.at(i).toRawForm();
#endif
                        cookiesListAll.removeAt(i);
                        deleted = true;
                    }
                }
            }
        } else {
            // Delete cookie(s) from the ones visible to the given "url".
            // Use the "name" to delete only the right one, otherwise all of them.
            QList<QNetworkCookie> cookiesListUrl = cookies(url);
            cookiesListAll = allCookies();
            for (int i = cookiesListAll.length() -1; i >= 0; --i) {
                if (cookiesListUrl.contains(cookiesListAll.at(i)) &&            //< if it part of the set of cookies visible at URL
                    (cookiesListAll.at(i).name() == name || name.isEmpty())) {  //< and if the name matches, or no name provided
                    // Remove this cookie
#if COOKIE_JAR_DEBUG
                    qDebug() << "CookieJar - Deleted" << cookiesListAll.at(i).toRawForm();
#endif
                    cookiesListAll.removeAt(i);
                    deleted = true;

                    if (!name.isEmpty()) {
                        // Only one cookie was supposed to be deleted: we are done here!
                        break;
                    }
                }
            }
        }

        // Put back the remaining cookies
        setAllCookies(cookiesListAll);
    }
    return deleted;
}

bool CookieJar::deleteCookies(const QString &url)
{
    if (isEnabled()) {
        if (url.isEmpty()) {
            // No URL provided: delete ALL the cookies in the CookieJar
            clearCookies();
            return true;
        }

        // No cookie name provided: delete all the cookies visible by this URL
#if COOKIE_JAR_DEBUG
        qDebug() << "Delete all cookies for URL:" << url;
#endif
        return deleteCookie("", url);
    }
    return false;
}

void CookieJar::clearCookies()
{
    if (isEnabled()) {
        setAllCookies(QList<QNetworkCookie>());
    }
}

void CookieJar::enable()
{
    m_enabled = true;
}

void CookieJar::disable()
{
    m_enabled = false;
}

bool CookieJar::isEnabled() const
{
    return m_enabled;
}

void CookieJar::appendToPages(WebPage *page)
{
    m_pages.append(page);
}

void CookieJar::removeFromPages(WebPage *page)
{
    m_pages.removeAll(page);
}

// private:
bool CookieJar::purgeExpiredCookies()
{
    QList<QNetworkCookie> cookiesList = allCookies();

    // If empty, there is nothing to purge
    if (cookiesList.isEmpty()) {
        return false;
    }

    // Check if any cookie has expired
    int prePurgeCookiesCount = cookiesList.count();
    QDateTime now = QDateTime::currentDateTime();
    for (int i = cookiesList.count() - 1; i >= 0; --i) {
        if (!cookiesList.at(i).isSessionCookie() && cookiesList.at(i).expirationDate() < now) {
#if COOKIE_JAR_DEBUG
            qDebug() << "CookieJar - Purged (expired)" << cookiesList.at(i).toRawForm();
#endif
            cookiesList.removeAt(i);
        }
    }

    // Set cookies and returns "true" if at least 1 cookie expired and has been removed
    if (prePurgeCookiesCount != cookiesList.count()) {
        setAllCookies(cookiesList);
        return true;
    }
    return false;
}

bool CookieJar::purgeSessionCookies()
{
    QList<QNetworkCookie> cookiesList = allCookies();

    // If empty, there is nothing to purge
    if (cookiesList.isEmpty()) {
        return false;
    }

    // Check if any cookie has expired
    int prePurgeCookiesCount = cookiesList.count();
    for (int i = cookiesList.count() - 1; i >= 0; --i) {
        if (cookiesList.at(i).isSessionCookie() || !cookiesList.at(i).expirationDate().isValid() || cookiesList.at(i).expirationDate().isNull()) {
#if COOKIE_JAR_DEBUG
            qDebug() << "CookieJar - Purged (session)" << cookiesList.at(i).toRawForm();
#endif
            cookiesList.removeAt(i);
        }
    }

    // Set cookies and returns "true" if at least 1 session cookie was found and removed
    if (prePurgeCookiesCount != cookiesList.count()) {
        setAllCookies(cookiesList);
        return true;
    }
    return false;
}

void CookieJar::save()
{
    if (isEnabled()) {
        // Get rid of all the Cookies that have expired
        purgeExpiredCookies();

#if COOKIE_JAR_DEBUG
#ifndef QT_NO_DEBUG_OUTPUT
        foreach (QNetworkCookie cookie, allCookies()) {
            qDebug() << "CookieJar - Saved" << cookie.toRawForm();
        }
#endif
#endif

        // Store cookies
        m_cookieStorage->setValue(QLatin1String("cookies"), QVariant::fromValue<QList<QNetworkCookie> >(allCookies()));
    }
}

void CookieJar::load()
{
    if (isEnabled()) {
        // Register a "StreamOperator" for this Meta Type, so we can easily serialize/deserialize the cookies
        qRegisterMetaTypeStreamOperators<QList<QNetworkCookie> >("QList<QNetworkCookie>");

        // Load all the cookies
        setAllCookies(qvariant_cast<QList<QNetworkCookie> >(m_cookieStorage->value(QLatin1String("cookies"))));

        // If any cookie has expired since last execution, purge and save before going any further
        if (purgeExpiredCookies()) {
            save();
        }

#if COOKIE_JAR_DEBUG
#ifndef QT_NO_DEBUG_OUTPUT
        foreach (QNetworkCookie cookie, allCookies()) {
            qDebug() << "CookieJar - Loaded" << cookie.toRawForm();
        }
#endif
#endif
    }
}

bool CookieJar::contains(const QNetworkCookie &cookie) const
{
    QList<QNetworkCookie> cookiesList = allCookies();
    for (int i = cookiesList.length() -1; i >= 0; --i) {
        if (cookie.name() == cookiesList.at(i).name() &&
            cookie.value() == cookiesList.at(i).value() &&
            (cookie.domain().isEmpty() || cookiesList.at(i).domain().prepend('.').endsWith(cookie.domain())) &&
            (cookie.path().isEmpty() || cookiesList.at(i).path() == cookie.path()) &&
            cookie.isSecure() == cookiesList.at(i).isSecure() &&
            cookie.isHttpOnly() == cookiesList.at(i).isHttpOnly() &&
            cookie.expirationDate().toMSecsSinceEpoch() == cookiesList.at(i).expirationDate().toMSecsSinceEpoch()
            ) {
            return true;
        }
    }

    return false;
}
